Skip to content

fix(parser): resolve npm identity via npm's own parser, refuse otherwise (#195) - #199

Merged
ViperJuice merged 23 commits into
mainfrom
fix/195-nopt-npm-identity
Aug 27, 2026
Merged

ViperJuice merged 23 commits into
mainfrom
fix/195-nopt-npm-identity

Conversation

@ViperJuice

@ViperJuice ViperJuice commented Aug 27, 2026 •

Copy link
Copy Markdown
Collaborator

See #195.

origin/main moved while this was in flight (2.5.2 and the workflow guards);
merged in, with the 2.5.2 nullable-spelling tests re-pointed at the two-path
fixture rather than dropped. No version bump originates here.

What this does

detect_package_type(command, args) derives a package identity from a server's
launch command line, and the freshness gate reads a matching identity as
positive confirmation
that a cached tool description still describes the
configured package. A wrong identity therefore serves one server another
server's tool descriptions.

That parser has been repaired five times in production (#180 → #192 → #194 →
#195 → the 2.5.2 nullable-boolean spelling). Every defect was in the rules
around the hand-written flag tables rather than a missing entry, and every one
produced a confident wrong answer. This replaces the guessing with npm's own
parser plus aggressive refusal.

For an npx/npm server, a persistent node child (src/pmcp/manifest/_npm_resolve.js)
loads the host npm's own nopt, its own @npmcli/config definitions and its
own npm-package-arg — through a createRequire rooted at the resolved
bin/npx-cli.js, so the modules always come from the same npm the pre-scan was
read from — and runs a verbatim port of npx-cli.js lines 10–123, including
all three of its flag sets, with switches recomputed from the host's live
definitions. The identity is remain[1], not remain[0]: npx-cli.js:7 splices
exec into argv, and exec is a real published package, so reading remain[0]
would have made gateway.update_server probe npx -y exec@latest --help for all
79 npx servers in the manifest.

The refusal gates

An allowlist of plain shapes, not a denylist of dangerous ones — three
adversarial rounds on a denylist each found another way for a confident answer
to be wrong, because every npm_config_* option is equally settable as a
command-line flag and npm keeps adding new ones.

Gate Refuses when
parsed config keys any key beyond --yes/--package. That includes --registry, --userconfig, --prefix, --cache, --call, --workspace, a shorthand like --silent (which expands to --loglevel), and unknown flags, which nopt reports as boolean keys
server env overlay npm_config_* (case-insensitive), PATH, HOME, NODE_PATH, NODE_OPTIONS, PREFIX, NVM_*
gateway process env npm_config_* or NODE_OPTIONS — sticky for the process
effective cwd npm would set a local prefix (package.json or node_modules in any ancestor), because a project .npmrc can rename the package and a node_modules/.bin entry means npm never reaches the registry
npm subcommand no package operand: npm run/start/test/create, a typo, bare npm -y pkg, and npm dlx (pnpm/yarn spelling — npm dlx probe is Unknown command, yet it sat in the tables' subcommand allowlist)
npm-package-arg type anything but tag/version/range. Notably alias: npx -y myalias@npm:left-pad really runs left-pad, and the corpus confirms it against the binary — the tables mint myalias-zz, a squattable different package
--package zero, or more than one distinct value
package operand it starts with -. The npm majors disagree about what such a token names — see below — so it cannot be named with certainty
npm subcommand, again the subcommand check runs first and unconditionally, so --package cannot short-circuit it. npm run/start/test/create/dlx/rum --package=X and bare npm --package=X all refuse
npm install/i/add a package config key is present (npm reads it for exec/x only), or the operand list is not exactly one package. npm install a b and npm install a c are two configurations, not one
npm locatability an npx/npm entry point is on PATH but its package root cannot be found. That is not absence, so it must not reach the tables
drift the spawn-time self-test fails, bin/npx-cli.js is not one this port was verified against, or npm's parser will not load

Tri-state, and only one state may consult the tables. IDENTITY returns the
spec raw (_strip_npm_tag stays in detect_package_type, because
update_server's pin detection needs the suffix). REFUSED returns None and
never scans the tables. UNAVAILABLE — spawn failed, i.e. no node or no
npm root — falls through to the tables, which is the pre-2.5.2 behaviour and all
a node-less host can do.

A failed self-test refuses; it does not degrade to the tables. A failed
self-test is precisely the evidence that the tables' model of npm is wrong, so
consulting them there is fail-open.

Resolved an internal contradiction in the plan's Drift section. It said both
"a hash mismatch takes the same REFUSED path — there is no UNAVAILABLE-with-reason
state; that phrasing was rev 3's and is gone" and, two sentences later, "a
mismatch produces the same UNAVAILABLE-with-reason + WARNING". Implemented as
REFUSED-for-lifetime + one WARNING, matching the sentence that declares the other
phrasing gone and matching acceptance criterion 5.

Measured cost

Zero on the shipped manifest. All 79 npm-family servers use the plain
npx -y <pkg> shape; all 79 resolve, and all 79 match the package the real npx
binary fetches.

The cost lands on unusual forms, and it is real — these are cases where the
resolver refuses and the binary would have resolved:

form binary fetches tables said
npx --pack zz bin zz (refused)
npx --frobnicate valpkg realbin valpkg (refused)
npm exec --loglevel silly aaa aaa aaa
npm exec --global true aaa aaa aaa
npx --userconfig X probe-a probe-a probe-a
npx --prefix X probe-a probe-a probe-a
npx --cache X probe-a probe-a probe-a
npx --package=p --call='echo hi' p p
npm create foo create-foo (refused)
npx -y myalias@npm:left-pad left-pad myalias-zz ← wrong

A refused server keeps running. It loses auto-update and version reporting, its
descriptions regenerate every refresh cycle, and check_staleness lists it
permanently stale. That is a cost, not a correctness problem — and it is why the
allowlist is drawn to cover the plain shape rather than as tightly as possible.

Runtime. 43 ms of one-time child startup (including the self-test corpus) and
~0.9 ms per query; 200 distinct resolves in 0.188 s with one spawn.
detect_package_type is synchronous and called from inside async coroutines, so
one-shot invocation (4.02 s for 79) was not an option.

Accepted residual

A package= or registry= line in a user or global .npmrc changes what npm
resolves and the resolver cannot see it. Project-level .npmrc is covered by the
local-prefix refusal, and npm_config_* in the gateway's own environment is now
covered by the process-environment check. The user/global rc file is the one
input that remains unguarded, and it is stated here rather than discovered later.

Operational note: a sticky refusal does not recover on its own — a
disabled resolver keeps no child, so nothing re-checks the host. It logs one
WARNING that now names the restart requirement, and gateway.health →
gateway_diagnostics.npm_identity reports the state on demand. Those two are the
only surfaces; without them a fleet-wide loss of auto-update would be invisible.
A transient child death is different: the respawn budget is time-based (60 s,
re-arming), so it cannot strand a long-running gateway.

Verification (measured, this branch)

pytest, everything except tests/runtime   3129 passed, 3 skipped, 25 deselected  (262.42s)
pytest tests/runtime -k "not stdio"       52 passed, 8 deselected                (63.03s)
ruff check src/ tests/                    All checks passed!
ruff format --check src/ tests/           118 files already formatted
mypy src/                                 Success: no issues found in 43 source files
differential corpus, seed 561426559       227 rows, 0 UNSAFE  -- on BOTH majors
  npm 11.19.0                             126 IDENTITY, 126 == binary, 5 beat the tables
  npm 10.8.2                              126 IDENTITY, 126 == binary, 5 beat the tables
  AC1 required-to-resolve (enforced)       89/89 IDENTITY and matching (79 manifest + 10)
cross-major invariance (same child,        286 cases, 0 diverging
  same corpus, 10.8.2 vs 11.19.0)
98-entry manifest, node-less PATH         98 pairs compared, 0 differing vs 2.5.2
node-less pytest (identity suites)        235 passed, 134 skipped
wheel install into a clean venv           helper OK: left-pad

The corpus now enforces AC1's positive half: every manifest row and every
req-* row must be IDENTITY and match the binary. Before, "zero unsafe rows"
judged every refusal safe, so a refuse-everything resolver passed — verified by a
negative control that stubs the resolver to refuse everything and now exits 1.

Where the resolver beats the tables, confirmed against the binary:

form resolver tables binary
npx --yes=maybe tok bin2 maybe tok maybe
npx --yes null aaa null (refused) null
npx --no-install -y=false --userconfig 1 --pack (fuzz) false (refused) false

Acceptance criterion 6 — an unconverted call site must be a type error:

$ # one caller reverted in memory, mypy run, original bytes written back
src/pmcp/manifest/refresher.py:308: error: Missing positional arguments "env",
    "cwd" in call to "get_package_version"  [call-arg]
Found 1 error in 1 file (checked 43 source files)

env/cwd are required and undefaulted on detect_package_type,
_npm_package_arg, get_package_version and _detect_effective_version_pin; a
test asserts they carry no default, so a habitual = None cannot creep back.

Deviations from the amendment of record

The coordinator's amendment (D1–D12) is reproduced verbatim below. Three
deviations, all measured:

  1. D9's discriminator does not discriminate. D9 asked for npx --pack zz bin
    → zz node-ful, ("unknown", None) node-less. Measured: nopt reports an
    unknown flag as a boolean config key ({pack: true}), which the step-1
    allowlist refuses — so it is ("unknown", None) on both paths. Substituted
    npx --yes=maybe tok (yes is allowlisted, so it survives the gate):
    node-ful maybe, node-less tok, binary maybe. The coordinator verified
    and accepted this.
  2. D6's set sizes are wrong (removedSwitches has 3 entries, not 10).
    Immaterial — the port is verbatim from the real file, all three sets included.
    Also accepted.
  3. D10/D5 classifications, chosen and stated rather than inferred: npm root
    not found → UNAVAILABLE (the plan names it); parser-load failure and a
    pre-handshake protocol violation → REFUSED-for-lifetime (npm is present and
    unmodellable, which is the self-test situation, not "we learned nothing").
    Sticky recovery needs a gateway restart; the transient-death budget is
    time-based.

Board findings on the diff, and what they changed

The diff was boarded twice. Six must-fixes, every one reproduced before it was
touched and re-measured after.

# Defect Before After
1 --package short-circuited the npm subcommand allowlist — #183 fully reopened npm run --package=pkg-a -- bin → ('npm','pkg-a'); same for start/test/create/dlx/rum/bare npm all ('unknown', None); npx --package=X -- bin still resolves
2 UNAVAILABLE assigned too broadly, and it is the one state that may use the tables missing packaged helper, and any Popen OSError, fell through to the tables and resolved npx --registry https://private.invalid probe → ('npm','probe') both REFUSE; only FileNotFoundError on node is UNAVAILABLE
3 The corpus did not enforce AC1 a refuse-everything resolver scored 0 unsafe and passed negative control now exits 1; 89 required rows must be IDENTITY and match
4 Memo hits bypassed the per-resolve npm re-stat an in-place npm upgrade mid-session served a stale identity indefinitely the memo is gone; every query re-stats (~0.5 ms)
5 The install family read --package and discarded extra operands npm install pkg-real --package=pkg-other → pkg-other (npm fetches pkg-real); npm install --package → true (npm fetches nothing); npm install a b and npm install a c → both a, one identity for two configs all ('unknown', None); npm install a, npm exec pkg-one pkg-two and npx -y pkg-one pkg-two still resolve
6 A legal spec containing a space killed a healthy child npx -y 'pkg@>=1.0 <2.0' → REFUSED + teardown, and the next server in the same cycle got "cooling down after a failure" resolves to pkg@>=1.0 <2.0; teardown is now reserved for a genuinely desynchronized stream

Finding 5 was caught by this harness's own randomized fuzz seed — which is why
that requirement existed. Three follow-ups were also closed: the refresher's
identity overlay now carries the credential variable name (an env_var of
npm_config_registry is manifest-controlled, passes validate_env_var_name, and
would have redirected npm without the gate seeing it); a parent-side spawn
failure is no longer sticky, since permission and resource errors are repairable
under a running gateway; and the one-per-process WARNING no longer claims
identity is disabled in the UNAVAILABLE case, where the tables still mint
identities.

Separately, running the board's requested npm 10.8.2 verification surfaced a
layout the resolver could not handle: the GitHub Actions runner's bundled node20
ships <prefix>/bin/npx as a copy of npx-cli.js rather than a symlink, so
walking up from it never reached the npm package and the resolver fell back to
the tables. It now also checks npm's own global layout — against the entry
point's own prefix only, because checking every level of the walk made a stray
npx in a temp directory adopt this host's unrelated /lib/node_modules/npm.

A cross-major divergence CI caught and this host could not

The first CI run failed, and the failure was correct. On npm 10.9.4 the
spawn-time self-test reported npm exec -- --flag-thing: expected null, got "--flag-thing" and disabled the resolver. Measured against both real
binaries
with a dead registry:

npm npa npm exec -- --flag-thing fetches npa says
10.9.4 12.0.2 /--flag-thing {type: 'range', name: '--flag-thing'}
11.19.0 13.0.2 /undefined {type: 'tag', name: undefined}

Two installed npms, two different packages, one command line. The original code
happened to refuse on npm 11 only because npa 13 returns no name — so it would
have minted --flag-thing as a confident identity on every npm 10 host, the
F2 defect the design set out to close, reopened through a version difference.

The fix refuses a package operand beginning with - before npa is consulted.
That is the only answer true on both majors, and it is what makes the
spawn-time self-test an invariant rather than a description of whichever npm
the author happened to have installed. Verified afterwards by running the
child's whole corpus — 248 cases, named + manifest + fuzz — under npm 10.9.4 and
npm 11.19.0 and diffing: 0 diverging.

This is worth stating plainly because the self-test is the mechanism that turns
a modelling error into a loud refusal rather than a quiet wrong answer, and it
did exactly that on its first exposure to an npm this machine does not have.

An environment finding worth recording

This development host has /tmp/package.json and /tmp/node_modules/.bin
containing real executables
. Two consequences, both verified:

  • A dead registry stops npm fetching but not executing — libnpmexec runs a
    matching local or global bin and returns into execution before any fetch. A
    differential harness rooted in /tmp would therefore have launched installed
    binaries
    . differential_npm_corpus.py refuses to run unless it finds a temp
    base with no npm local prefix, and aborts loudly naming every candidate it
    tried rather than silently relocating.
  • pytest puts tmp_path under /tmp, so any monkeypatch.chdir(tmp_path) test
    silently flips from resolving to refusing. That is nasty in one direction only:
    a test asserting a refusal then passes for the wrong reason and looks green
    forever. tests/conftest.py now documents this at the top of the file, and the
    one affected test class pins its identity path explicitly.

Pre-existing failure, not from this change

tests/runtime's stdio-harness tests (test_downstream_stdio.py and
TestStdioEmitterReachesDispatch) hang on this host. Proven not to be this
change:

  • they hang with a genuinely node-less PATH, where the resolver spawns no child
    at all and identity is byte-for-byte 2.5.2;
  • they hang identically at the base commit ea8333c, run from a clean
    git archive export with PYTHONPATH pointed at it (exit=124 for both);
  • nothing in those tests touches package identity.

The remaining 52 runtime tests pass. This wants its own investigation.


Amendment of record (coordinator, quoted verbatim)

D1 — NEW HOLE, design-side. Gate the gateway's OWN process environment too. env npm_config_package=evil-pkg-abcz npm_config_registry=http://127.0.0.1:9 npx -y probe-abcz fetches http://127.0.0.1:9/evil-pkg-abcz. Add a one-time startup check of os.environ for any npm_config_* (case-insensitive) or NODE_OPTIONS; if present, the resolver REFUSES for its lifetime and logs exactly one WARNING naming the offending key.

D2 — AC3 must be parametrized, not a three-item list. Required for every step-1/step-4 refusal path for which the 2.5.2 tables return non-None, keeping the two-distinct---package carve-out.

D3 — AC2 has no pass condition. Add: zero unsafe rows — every recorded verdict must show resolver == binary OR resolver == REFUSED; rows where the binary made no fetch or crashed require REFUSED.

D4 — Randomize the fuzz seed per run and print it.

D5 — The memo defeats the drift defence. Include the child generation / handshake npmVersion in the memo key, or flush the memo on every respawn and npm-version change. Also specify: (a) does a REFUSED-for-lifetime resolver still re-stat, or does recovery need a gateway restart? (b) respawn-once with no re-arm strands a days-running gateway after two transient child deaths — make the budget time-based or re-armed.

D6 — The npx-cli port spec is wrong about ranges and sets. The loop is lines 66–124, and removedSwitches, switches and opts all live outside the cited 63–124. Read the real file and enumerate all three sets.

D7 — parsed.package is a LIST. "A single --package value" means len(...) == 1, not truthiness. --package="" must be tested by key presence, not JS truthiness.

D8 — AC4 mixes return conventions. Name the function per clause.

D9 — AC7 cannot detect a resolver that ignores PATH removal. Add one discriminating form: npx --pack zz bin must be zz node-ful and ("unknown", None) node-less.

D10 — Two failure modes are unclassified: a pre-handshake protocol violation, and loadParser() failing to require. Classify each as REFUSED-lifetime or UNAVAILABLE and say which.

D11 — The hung-child criterion is vacuous where it runs. Split into two criteria on two hosts.

D12 — Factual fix: the manifest is 79/79 plain shape, not 78/79.

Also worth surfacing: a fleet-wide auto-update disable behind a single WARNING is easy to miss — if gateway.health has a natural place to report resolver state, add it.

🤖 Generated with Claude Code

https://claude.ai/code/session_01T61zMNiPF4K29186r3uqc3

ViperJuice and others added 19 commits August 26, 2026 21:46
Three of four seats found blocking defects; two independently found the same one.

R1 tri-state: rev 1 returned str|None and fell back to the tables on ANY None,
which contradicts its own 'drift refuses' rule -- a refusal would hit the table
scan and remint the collision. Now Identity/REFUSED/UNAVAILABLE, and only
UNAVAILABLE falls back.

R2 mapping: rev 1 left remain+package -> identity unspecified. A naive
skip-first-token makes 'npm run mcp' resolve to 'mcp', which update_server then
installs and executes -- reopening #183, closed hours earlier. Mapping is now
specified and pinned.

R3 test paths: rev 1 moved the existing #182/#183 regression set onto the
fallback fixture, so it would have stopped covering production on any node-ful
host. Now parametrized over both paths.

Also: 'exact by construction' was false (npm_config_package splits identity from
argv); the npx-cli.js SHA pin never fires (byte-identical 10.8.2 -> 11.19.0) and
guarded the wrong component, replaced by an npm-major ceiling; per-request
try/catch is mandatory (--__proto__ throws inside nopt); the idle reaper plus
sticky-unavailable would have killed the feature after one idle period; and the
drift criterion was vacuous.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01T61zMNiPF4K29186r3uqc3
Operator chose to stop modelling npm and refuse instead. Each board round on
rev 1/rev 2 found a new way a CONFIDENT answer is wrong, and the surface kept
growing: workspace bin resolution, .npmrc at three levels, per-server cwd, and
libnpmexec differing WITHIN npm 11.x while npx-cli.js stays byte-identical.

Measured basis for narrowing: 78 of the manifest's 79 npm servers use the plain
'npx -y <pkg>' shape. None sets cwd, npm_config_*, --call, multiple --package,
or workspace flags. Refusal costs nothing on the shipped fleet.

Blocking findings folded in:
- remain[0] after a faithful npx pre-scan is 'exec', and 'exec' is a real
  published package -- rev 2 would have probed 'npx -y exec@latest --help' for
  all 79 npx servers.
- An invalid spec becomes the package 'undefined', which also exists on the
  registry; specs now validate through npm-package-arg.
- --call was unreachable behind the --package rule.
- dlx is not an npm subcommand.
- env/cwd are now REQUIRED parameters through detect_package_type and all six
  call sites; rev 2 declared env an identity input but no production signature
  carried it.
- Drift detection is behavioural (a spawn-time invariant corpus) rather than a
  version ceiling, which was proven to guard at the wrong granularity.
- AC3 rewritten to a call counter on the table-scan entry point: a seat wrote a
  faithful-but-wrong implementation that passed rev 2's wording while the scan
  demonstrably ran.

User and global .npmrc remain unguarded and are named as the one known
open hole rather than left to be discovered.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01T61zMNiPF4K29186r3uqc3
Rev 3 boarded with six blocking findings, four verified confident-wrong answers.
Root cause was structural: the gate was a denylist of 'unusual', so every round
found another unusual thing. Every npm_config_* option is equally settable as a
FLAG, and rev 3's denylist covered only env -- verified: --userconfig, --prefix
and --registry each redirect resolution while leaving nopt's remain pristine.

Rev 4 flips it: refuse unless every parsed nopt config key is in {yes, package}.
nopt hands back the parsed keys, so this is nearly free and closes keys npm has
not invented yet. 78 of 79 shipped servers parse to {yes} alone.

Other verified findings folded in:
- An alias spec (pkg@npm:real) has a VALID npa name but runs a different
  package, so rev 3's 'no valid name -> refuse' passed it. Now requires npa type
  in {tag, version, range}.
- The effective cwd, not the server cwd, is what npm resolves from; rev 3's
  non-goals wrongly claimed the project .npmrc case was closed.
- Server env setting PATH selects WHICH npm runs, and the two npm trees on this
  host genuinely disagree.
- Refusal is safe, but not for the reason rev 3 gave: the coarse fallback
  identity DOES collide; safety comes from the 'unknown' type failing every
  comparison closed. Real cost stated: refused servers regenerate every cycle.
- The call-site list was a closure, not six sites: get_package_version and
  _detect_effective_version_pin both grow, plus ~10 test fakes mypy never sees.
- AC3 was again satisfiable by a broken implementation -- rev 3 added a counter
  and dropped the result assertion. Now asserts the result.
- AC1 was satisfiable by resolving almost nothing; now names required forms.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01T61zMNiPF4K29186r3uqc3
…ck (#195)

Late CLI seats on rev 3 returned two DISAGREE with findings rev 4 missed.

Worst: a failed spawn-time self-test was specified to report UNAVAILABLE, which
means fall back to the tables. That is a fail-open in exactly the situation that
proves the host parser is not modelled correctly -- responding by consulting the
known-incomplete 2.5.2 tables is the worst available choice. Self-test failure
and hash mismatch now REFUSE for the process lifetime; UNAVAILABLE is reserved
for spawn failure, where we learned nothing about npm.

Two of the plan's own verification commands proved nothing, both verified:
- PATH=/usr/bin:/bin still contains node, npm and npx on this host, and excludes
  uv, so the 'node-less' run was neither node-less nor runnable.
- The wheel smoke ended in Python's '...' no-op, so it imported the lazy
  resolver and never loaded the packaged .js it exists to check.

AC3 was unsatisfiable: it demanded the tables return a non-None wrong answer for
EVERY gate, but they already return ('unknown', None) for two distinct
--package. Now scoped to the gates where they genuinely mislead.

AC1 fought its own gate: the corpus observes npm via npm_config_registry, which
the gate refuses, so feeding that env to resolve() refuses everything. The dead
registry now goes to the binary only, and AC1 additionally requires a case where
the tables are wrong and the resolver matches -- otherwise the 79 plain servers
go green on tables alone.

Also: a ready handshake before the first query (the self-test shares stdout, so
the first resolve could read a self-test line as its answer); createRequire from
the resolved npx-cli.js; the per-request catch must wrap the pre-scan too; npx
switches must keep npx-cli's hardcoded extras; a hashable, explicitly-defined
memo key; ServerConfig has no cwd (LocalMcpServerConfig does); and
get_package_version's independent caller at handlers.py:5242.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01T61zMNiPF4K29186r3uqc3
Rev 5 boarded 3 DISAGREE. Its gate, read literally, would have refused every npm
server on every host -- twice over:

- The env gate said 'effective env (sanitized_subprocess_env overlay)', but that
  function is os.environ.copy() plus the overlay, and every Unix process has
  PATH. It now reads the OVERLAY dicts (extra_env / config.env). HOME added:
  HOME=/x with /x/.npmrc package=other redirects resolution.
- The cwd gate named package.json and .npmrc. Missing node_modules left the
  local-bin case open, and walking for .npmrc matches ~/.npmrc for any cwd under
  /home/viperjuice. Now gates on npm's own localPrefix rule, read from its source:
  hasPackageJson || dirExists(p, 'node_modules').

The memo could not capture mutable state -- creating .npmrc or node_modules
after caching, or upgrading npm, changes resolution without changing the key,
and a hit skipped the version re-stat. Entries are now stamped and revalidated.

The differential harness was unsafe: a dead registry stops fetching but not
executing, and libnpmexec returns into execution before any fetch, so running
79 manifest names would have launched installed server code. Now hermetic.

AC6 still said a failed self-test 'degrades to the tables' -- the same fail-open
rev 5 removed from the design but left in the acceptance contract.

Per operator decision: this goes to implementation without a sixth plan-board
round. Rounds 1-3 found invisible wrong-identity defects, which need a board;
round 5's are refuse-everything defects that fail loudly on the first test run.
The DIFF gets boarded instead.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01T61zMNiPF4K29186r3uqc3
…oracle (#195)

Ports the npx-cli.js pre-scan verbatim, loads the host npm's nopt,
config definitions and npm-package-arg through a createRequire rooted
at the resolved npx-cli.js, and answers one question over NDJSON:
can this server's package be named with certainty? Everything else
refuses.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01T61zMNiPF4K29186r3uqc3
… child (#195)

IDENTITY / REFUSED / UNAVAILABLE, where only UNAVAILABLE (spawn failed:
no node, no npm root) may fall back to the flag tables. Parent-side
gates (server env overlay, gateway process env, effective-cwd local
prefix) need no child. Memo is dropped with the child so the drift
re-stat stays live; respawn budget is time-based so a long-running
gateway is never stranded.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01T61zMNiPF4K29186r3uqc3
detect_package_type, _npm_package_arg, get_package_version and
_detect_effective_version_pin all grow required env/cwd parameters, and
every production call site is converted. Undefaulted on purpose: a
defaulted None is the same silent fail-open this change exists to
remove, and requiring them turns an unconverted caller into a type
error rather than a wrong answer.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01T61zMNiPF4K29186r3uqc3
Every existing npm identity test now runs under BOTH production paths --
resolver-active and resolver-unavailable -- rather than being moved onto
the fallback fixture, which would have stopped covering production on
every node-ful host. Where the two answers differ the test spells out
both; the resolver refuses in every such case but one (npx --yes null
zz), where it matches the real binary and the tables over-refuse.

Also converts the ~17 get_package_version fakes that mypy cannot see.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01T61zMNiPF4K29186r3uqc3
…#195)

AC3 is parametrized over EVERY refusal gate for which the 2.5.2 tables
return a non-None wrong answer, asserted on the result, with the table
scan counter instrumented inside the scan itself rather than
monkeypatched. Self-test failure, hash mismatch and parser-load failure
are each proven end-to-end against a fixture npm root.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01T61zMNiPF4K29186r3uqc3
pyproject names the helper explicitly so a future include-rule change
cannot drop it silently, and install-smoke now RESOLVES a package
through the installed helper rather than importing a module and
asserting nothing.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01T61zMNiPF4K29186r3uqc3
A timeout, death, protocol violation or STALE tears the child down and
returns REFUSED for that one request. Memoising it made one transient
stall permanently disable identity for that argv -- a gateway re-queries
the same fixed server set every cycle, so the entry would be re-served
for the process lifetime even after a healthy respawn. Also returns a
sticky refusal before the memo lookup rather than after.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01T61zMNiPF4K29186r3uqc3
Resolver vs flag tables vs the REAL npm binary, over every historical
form, every gate, the 79 manifest npm servers and a randomly-seeded
fuzz component. Pass condition is zero unsafe rows -- resolver matches
the binary or refuses, and a no-fetch/crash row must refuse -- plus at
least one case where the tables are wrong and the resolver is right.

Hermetic per invocation: empty temp cwd, temp HOME/prefix/cache, dead
registry, process-group kill. The temp base is checked for an npm local
prefix and the run aborts if none is clean, because a dead registry
stops npm fetching but NOT executing a local or global bin.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01T61zMNiPF4K29186r3uqc3
…195)

States the refusal gates plainly, that refusal reduces auto-update
coverage rather than guessing, the node-less fallback, and the one
known residual (user/global .npmrc).

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01T61zMNiPF4K29186r3uqc3
)

A sticky refusal disables auto-update for every npm server behind a
single WARNING, which is easy to miss in a gateway that runs for days.
status_summary() never spawns -- it reports only what is already known,
so a diagnostic call cannot start a subprocess or report a healthy
resolver on a gateway that has never resolved anything.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01T61zMNiPF4K29186r3uqc3
…ment (#195)

A resolver that refuses for its process lifetime keeps no child, so
nothing re-checks the host and it does not recover on its own. The
WARNING now says so and points at gateway.health, and tests/conftest.py
documents the tmp_path trap: a test that passes only because npm
identity refused is a false green.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01T61zMNiPF4K29186r3uqc3
…tity

# Conflicts:
#	CHANGELOG.md
#	tests/test_version_checker.py
CI caught a genuine cross-major divergence this host could not see.
Measured against both binaries with a dead registry:

  npm 10.9.4   npm exec -- --flag-thing  fetches /--flag-thing
  npm 11.19.0  the same argv             fetches /undefined

npa 12 reports {range, '--flag-thing'} where npa 13 reports {tag,
undefined}. Two installed npms, two different packages, one command
line -- so this is exactly an identity that cannot be stated with
certainty, and the previous code minted '--flag-thing' on npm 10.

Refusing before npa sees the token also makes the spawn-time self-test
an INVARIANT rather than a description of whichever npm the author had:
without it the self-test passed on npm 11 and FAILED on npm 10, which
disabled the whole resolver on every npm 10 host.

Verified: the child's answers over 248 corpus cases are byte-identical
between npm 10.9.4 and npm 11.19.0.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01T61zMNiPF4K29186r3uqc3
ViperJuice and others added 4 commits August 27, 2026 10:47
1. --package no longer short-circuits the npm subcommand allowlist.
   #183 was fully reopened: `npm run/start/test/create/dlx/rum
   --package=X` and bare `npm --package=X` all minted ('npm','X'), and
   handlers.py would have fetched and executed X@latest off the public
   registry for a command line that runs a package.json script. The
   subcommand check now runs first and unconditionally; npx, which has
   no subcommand, still resolves --package.

2. UNAVAILABLE is now reserved for CONFIRMED node absence. A missing
   packaged helper (broken install) and any non-FileNotFoundError OSError
   from Popen (permission, resource limits) used to route to UNAVAILABLE
   and therefore to the 2.5.2 tables; both were reproduced resolving
   `npx --registry https://private.invalid probe` to 'probe'. Both now
   REFUSE.

3. The corpus enforces AC1's positive half. 'Zero unsafe rows' judged
   every refusal safe, so a refuse-everything resolver passed. Every
   manifest row and every req-* row must now be IDENTITY and match the
   binary, with counts printed; verified by a negative control that now
   exits 1.

4. The memo is gone. It was dropped with the child and on a handshake
   version change, but both fire only at lifecycle boundaries -- a cache
   HIT never reached the child, so the per-resolve npm re-stat could not
   fire and an in-place npm upgrade mid-session would have served a stale
   identity indefinitely. Every query now re-stats; ~0.5 ms each.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01T61zMNiPF4K29186r3uqc3
… locate (#195)

Found while running the board's requested npm 10.8.2 verification: the
GitHub Actions runner's bundled node20 ships <prefix>/bin/npx as a COPY
of npx-cli.js rather than a symlink, so walking up from it never reaches
the npm package. The resolver reported UNAVAILABLE and fell through to
the flag tables -- the same fail-open as the missing-helper case.

rootFromEntryPoint now also checks npm's own global layout
(<prefix>/lib/node_modules/npm), against the ENTRY POINT'S OWN prefix
only: checking it at every level of the walk made a stray npx in /tmp
adopt this host's /lib/node_modules/npm. And when no root is found while
an npx/npm entry point IS on PATH, the child now REFUSES rather than
reporting UNAVAILABLE; only a host with neither on PATH is UNAVAILABLE.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01T61zMNiPF4K29186r3uqc3
… kill the child (#195)

MUST-FIX 5. npm reads the `package` config for exec/x ONLY, and the
install family takes a LIST. Treating it like exec gave three wrong
answers, all verified against the real binary:

  npm install pkg-real --package=pkg-other -> 'pkg-other' (npm fetches pkg-real)
  npm install --package                    -> 'true'      (npm fetches nothing)
  npm install a b -> 'a'  and  npm install a c -> 'a'      (one identity, #180)

install/i/add now refuse a `package` key and refuse unless exactly one
positional follows. exec/x are untouched: their trailing positionals are
arguments to the binary, so `npm exec pkg-one pkg-two` and
`npx -y pkg-one pkg-two` still resolve.

MUST-FIX 6. `npx -y 'pkg@>=1.0 <2.0'` is a legal npa range and npm
fetches `pkg`. The parent's spec-shape check rejected whitespace and
TERMINATED a healthy child for it -- the id matched and the JSON parsed,
so the stream was never desynchronized -- and the next server in the
same refresh cycle got 'cooling down after a failure'. One such manifest
entry disabled npm identity fleet-wide for the cooldown window, every
cycle. Teardown is now reserved for a genuinely desynchronized stream
(timeout, death, malformed JSON, id mismatch); a record the parent
merely dislikes refuses that one query and leaves the child alive.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01T61zMNiPF4K29186r3uqc3
(a) The refresher's identity overlay now carries the credential
    VARIABLE NAME. The old comment asserted a credential name 'cannot
    match the gate's key patterns' -- false: env_var/secret_key are
    manifest-controlled and 'npm_config_registry' passes
    validate_env_var_name, so it would be injected at spawn and redirect
    npm without the gate ever seeing it. Names only; values stay empty,
    because the gate reads keys and must never read a secret.

(b) A PARENT-side spawn failure (permission, resource limit, missing
    helper) is no longer sticky. Those are repairable under a running
    gateway, and making them permanent turned a transient condition into
    a fleet-wide loss of auto-update for the process lifetime. They
    refuse and back off on the re-arming cooldown; only what the CHILD
    reports about the host stays sticky.

(c) The one-per-process WARNING now describes the outcome it is about.
    A sticky UNAVAILABLE still MINTS identities, from the flag tables --
    the REFUSED wording ('servers will report package_type=unknown') was
    simply false for it.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01T61zMNiPF4K29186r3uqc3
@ViperJuice
ViperJuice merged commit 7aa7d4e into main Aug 27, 2026
13 checks passed
@ViperJuice
ViperJuice deleted the fix/195-nopt-npm-identity branch August 27, 2026 11:10
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant